/*
 * This file is part of Advanced ABX
 *
 * Copyright 2010 Christian Uebber
 *
 * Advanced ABX is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 2, as
 * published by the Free Software Foundation.
 *
 * Advanced ABX is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Advanced ABX.  If not, see <http://www.gnu.org/licenses/>.
 */

package de.uebber.sound.lib;

import javax.sound.sampled.*;
import java.io.ByteArrayInputStream;
import java.io.IOException;

/**
 * Created by IntelliJ IDEA.
 * User: christian
 * Date: 24.04.2010
 * Time: 14:02:07
 * To change this template use File | Settings | File Templates.
 */
public class ArrayClip implements Clip {

    private byte[] audioData;

    protected int frameLength, frameSize, framePos;

    protected AudioFormat format;

    protected SourceDataLine dataLine;

    protected volatile boolean active;

    protected long timeStarted;

    protected int loopCount, loopStart, loopEnd;

    /**
     * Opens the clip, meaning that it should acquire any required
     * system resources and become operational.  The clip is opened
     * with the format and audio data indicated.
     * If this operation succeeds, the line is marked as open and an
     * <code>{@link javax.sound.sampled.LineEvent.Type#OPEN OPEN}</code> event is dispatched
     * to the line's listeners.
     * <p/>
     * Invoking this method on a line which is already open is illegal
     * and may result in an IllegalStateException.
     * <p/>
     * Note that some lines, once closed, cannot be reopened.  Attempts
     * to reopen such a line will always result in a
     * <code>{@link javax.sound.sampled.LineUnavailableException}</code>.
     *
     * @param format     the format of the supplied audio data
     * @param data       a byte array containing audio data to load into the clip
     * @param offset     the point at which to start copying, expressed in
     *                   <em>bytes</em> from the beginning of the array
     * @param bufferSize the number of <em>bytes</em>
     *                   of data to load into the clip from the array.
     * @throws javax.sound.sampled.LineUnavailableException
     *                                  if the line cannot be
     *                                  opened due to resource restrictions
     * @throws IllegalArgumentException if the buffer size does not represent
     *                                  an integral number of sample frames,
     *                                  or if <code>format</code> is not fully specified or invalid
     * @throws IllegalStateException    if the line is already open
     * @throws SecurityException        if the line cannot be
     *                                  opened due to security restrictions
     * @see #close
     * @see #isOpen
     * @see javax.sound.sampled.LineListener
     */
    @Override
    public void open(AudioFormat format, byte[] data, int offset, int bufferSize) throws LineUnavailableException {

        if (bufferSize % format.getFrameSize() != 0)
            throw new IllegalArgumentException("Buffer size must be multiple of frame size!");

        AudioInputStream ais = new AudioInputStream(new ByteArrayInputStream(data, offset, bufferSize), format,
                bufferSize / format.getFrameSize());
        try {
            open(ais);
        } catch (IOException e) {
            // Should not happen since we are reading directly from an already existing array.
            throw new RuntimeException("Something is seriously broken!", e);
        }
    }

    /**
     * Opens the clip with the format and audio data present in the provided audio
     * input stream.  Opening a clip means that it should acquire any required
     * system resources and become operational.  If this operation
     * input stream.  If this operation
     * succeeds, the line is marked open and an
     * <code>{@link javax.sound.sampled.LineEvent.Type#OPEN OPEN}</code> event is dispatched
     * to the line's listeners.
     * <p/>
     * Invoking this method on a line which is already open is illegal
     * and may result in an IllegalStateException.
     * <p/>
     * Note that some lines, once closed, cannot be reopened.  Attempts
     * to reopen such a line will always result in a
     * <code>{@link javax.sound.sampled.LineUnavailableException}</code>.
     *
     * @param stream an audio input stream from which audio data will be read into
     *               the clip
     * @throws javax.sound.sampled.LineUnavailableException
     *                                  if the line cannot be
     *                                  opened due to resource restrictions
     * @throws java.io.IOException      if an I/O exception occurs during reading of
     *                                  the stream
     * @throws IllegalArgumentException if the stream's audio format
     *                                  is not fully specified or invalid
     * @throws IllegalStateException    if the line is already open
     * @throws SecurityException        if the line cannot be
     *                                  opened due to security restrictions
     * @see #close
     * @see #isOpen
     * @see javax.sound.sampled.LineListener
     */
    @Override
    public void open(AudioInputStream stream) throws LineUnavailableException, IOException {

        // Convert uncommon formats to signed PCM
        if (stream.getFormat().getEncoding() != AudioFormat.Encoding.PCM_SIGNED) {
            stream = AudioSystem.getAudioInputStream(AudioFormat.Encoding.PCM_SIGNED, stream);
        }

        frameLength = (int) stream.getFrameLength();
        format = stream.getFormat();
        frameSize = format.getFrameSize();

        // Initialize internal buffer
        int len = frameLength * frameSize;
        audioData = new byte[len];

        // Fill it from input stream
        int numRead = 0;
        int offset = 0;
        while (numRead != -1) {
            numRead = stream.read(audioData, offset, len);
            offset += numRead;
        }

        setLoopPoints(0, -1);
        framePos = 0;

        active = false;

        // Acquire and open SourceDataLine
        dataLine = AudioSystem.getSourceDataLine(format);
        dataLine.open(format);
    }

    /**
     * Obtains the media length in sample frames.
     *
     * @return the media length, expressed in sample frames,
     *         or <code>AudioSystem.NOT_SPECIFIED</code> if the line is not open.
     * @see javax.sound.sampled.AudioSystem#NOT_SPECIFIED
     */
    @Override
    public int getFrameLength() {
        return frameLength;
    }

    /**
     * Obtains the media duration in microseconds
     *
     * @return the media duration, expressed in microseconds,
     *         or <code>AudioSystem.NOT_SPECIFIED</code> if the line is not open.
     * @see javax.sound.sampled.AudioSystem#NOT_SPECIFIED
     */
    @Override
    public long getMicrosecondLength() {
        return framesToMicroseconds(frameLength);
    }

    /**
     * Sets the media position in sample frames.  The position is zero-based;
     * the first frame is frame number zero.  When the clip begins playing the
     * next time, it will start by playing the frame at this position.
     * <p/>
     * To obtain the current position in sample frames, use the
     * <code>{@link javax.sound.sampled.DataLine#getFramePosition getFramePosition}</code>
     * method of <code>DataLine</code>.
     *
     * @param frames the desired new media position, expressed in sample frames
     */
    @Override
    public void setFramePosition(int frames) {
        framePos = frames;
    }

    /**
     * Sets the media position in microseconds.  When the clip begins playing the
     * next time, it will start at this position.
     * The level of precision is not guaranteed.  For example, an implementation
     * might calculate the microsecond position from the current frame position
     * and the audio sample frame rate.  The precision in microseconds would
     * then be limited to the number of microseconds per sample frame.
     * <p/>
     * To obtain the current position in microseconds, use the
     * <code>{@link javax.sound.sampled.DataLine#getMicrosecondPosition getMicrosecondPosition}</code>
     * method of <code>DataLine</code>.
     *
     * @param microseconds the desired new media position, expressed in microseconds
     */
    @Override
    public void setMicrosecondPosition(long microseconds) {
        framePos = microsecondsToFrames(microseconds);
    }

    /**
     * Sets the first and last sample frames that will be played in
     * the loop.  The ending point must be greater than
     * or equal to the starting point, and both must fall within the
     * the size of the loaded media.  A value of 0 for the starting
     * point means the beginning of the loaded media.  Similarly, a value of -1
     * for the ending point indicates the last frame of the media.
     *
     * @param start the loop's starting position, in sample frames (zero-based)
     * @param end   the loop's ending position, in sample frames (zero-based), or
     *              -1 to indicate the final frame
     * @throws IllegalArgumentException if the requested
     *                                  loop points cannot be set, usually because one or both falls outside
     *                                  the media's duration or because the ending point is
     *                                  before the starting point
     */
    @Override
    public void setLoopPoints(int start, int end) {
        if (end == -1)
            end = frameLength - 1;
        if (start < 0 || end >= frameLength || start > end)
            throw new IllegalArgumentException("Loop points out of bounds or end before start!");
        loopStart = start;
        loopEnd = end;
    }

    /**
     * Starts looping playback from the current position.   Playback will
     * continue to the loop's end point, then loop back to the loop start point
     * <code>count</code> times, and finally continue playback to the end of
     * the clip.
     * <p/>
     * If the current position when this method is invoked is greater than the
     * loop end point, playback simply continues to the
     * end of the clip without looping.
     * <p/>
     * A <code>count</code> value of 0 indicates that any current looping should
     * cease and playback should continue to the end of the clip.  The behavior
     * is undefined when this method is invoked with any other value during a
     * loop operation.
     * <p/>
     * If playback is stopped during looping, the current loop status is
     * cleared; the behavior of subsequent loop and start requests is not
     * affected by an interrupted loop operation.
     *
     * @param count the number of times playback should loop back from the
     *              loop's end position to the loop's  start position, or
     *              <code>{@link #LOOP_CONTINUOUSLY}</code> to indicate that looping should
     *              continue until interrupted
     */
    @Override
    public void loop(int count) {
        if (loopEnd / frameSize >= framePos)
            loopCount = count;
        start();
    }

    /**
     * Drains queued data from the line by continuing data I/O until the
     * data line's internal buffer has been emptied.
     * This method blocks until the draining is complete.  Because this is a
     * blocking method, it should be used with care.  If <code>drain()</code>
     * is invoked on a stopped line that has data in its queue, the method will
     * block until the line is running and the data queue becomes empty.  If
     * <code>drain()</code> is invoked by one thread, and another continues to
     * fill the data queue, the operation will not complete.
     * This method always returns when the data line is closed.
     *
     * @see #flush()
     */
    @Override
    public void drain() {
        dataLine.drain();
    }

    /**
     * Flushes queued data from the line.  The flushed data is discarded.
     * In some cases, not all queued data can be discarded.  For example, a
     * mixer can flush data from the buffer for a specific input line, but any
     * unplayed data already in the output buffer (the result of the mix) will
     * still be played.  You can invoke this method after pausing a line (the
     * normal case) if you want to skip the "stale" data when you restart
     * playback or capture. (It is legal to flush a line that is not stopped,
     * but doing so on an active line is likely to cause a discontinuity in the
     * data, resulting in a perceptible click.)
     *
     * @see #stop()
     * @see #drain()
     */
    @Override
    public void flush() {
        dataLine.flush();
    }

    /**
     * Allows a line to engage in data I/O.  If invoked on a line
     * that is already running, this method does nothing.  Unless the data in
     * the buffer has been flushed, the line resumes I/O starting
     * with the first frame that was unprocessed at the time the line was
     * stopped. When audio capture or playback starts, a
     * <code>{@link javax.sound.sampled.LineEvent.Type#START START}</code> event is generated.
     *
     * @see #stop()
     * @see #isRunning()
     * @see javax.sound.sampled.LineEvent
     */
    @Override
    public void start() {

        if (active)
            return;

        new Thread() {
            @Override
            public void run() {
                dataLine.start();

                active = true;

                do {
                    timeStarted = System.currentTimeMillis() * 1000 - framesToMicroseconds(getFramePosition());

                    while (active &&
                            ((loopCount > 0 && framePos <= loopEnd) || (loopCount == 0 && framePos < frameLength))) {
                        int bytePos = framePos * frameSize;
                        framePos += dataLine.write(audioData, bytePos, Math.min(dataLine.available(),
                                (loopCount > 0 ? (loopEnd + 1) * frameSize : audioData.length) - bytePos)) / frameSize;
                        if (framePos != 0 && dataLine.getBufferSize() == dataLine.available())
                            System.err.println("Buffer underrun!");
                    }

                    if (loopCount > 0) {
                        framePos = loopStart;
                    }
                } while (active && loopCount > 0 && --loopCount >= 0);

                active = false;
            }
        }.start();
    }

    /**
     * Stops the line.  A stopped line should cease I/O activity.
     * If the line is open and running, however, it should retain the resources required
     * to resume activity.  A stopped line should retain any audio data in its buffer
     * instead of discarding it, so that upon resumption the I/O can continue where it left off,
     * if possible.  (This doesn't guarantee that there will never be discontinuities beyond the
     * current buffer, of course; if the stopped condition continues
     * for too long, input or output samples might be dropped.)  If desired, the retained data can be
     * discarded by invoking the <code>flush</code> method.
     * When audio capture or playback stops, a <code>{@link javax.sound.sampled.LineEvent.Type#STOP STOP}</code> event is generated.
     *
     * @see #start()
     * @see #isRunning()
     * @see #flush()
     * @see javax.sound.sampled.LineEvent
     */
    @Override
    public void stop() {
        loopCount = 0;
        active = false;
        dataLine.stop();
    }

    /**
     * Indicates whether the line is running.  The default is <code>false</code>.
     * An open line begins running when the first data is presented in response to an
     * invocation of the <code>start</code> method, and continues
     * until presentation ceases in response to a call to <code>stop</code> or
     * because playback completes.
     *
     * @return <code>true</code> if the line is running, otherwise <code>false</code>
     * @see #start()
     * @see #stop()
     */
    @Override
    public boolean isRunning() {
        return dataLine.isRunning();
    }

    /**
     * Indicates whether the line is engaging in active I/O (such as playback
     * or capture).  When an inactive line becomes active, it sends a
     * <code>{@link javax.sound.sampled.LineEvent.Type#START START}</code> event to its listeners.  Similarly, when
     * an active line becomes inactive, it sends a
     * <code>{@link javax.sound.sampled.LineEvent.Type#STOP STOP}</code> event.
     *
     * @return <code>true</code> if the line is actively capturing or rendering
     *         sound, otherwise <code>false</code>
     * @see #isOpen
     * @see #addLineListener
     * @see #removeLineListener
     * @see javax.sound.sampled.LineEvent
     * @see javax.sound.sampled.LineListener
     */
    @Override
    public boolean isActive() {
        return active;
    }

    /**
     * Obtains the current format (encoding, sample rate, number of channels,
     * etc.) of the data line's audio data.
     * <p/>
     * <p>If the line is not open and has never been opened, it returns
     * the default format. The default format is an implementation
     * specific audio format, or, if the <code>DataLine.Info</code>
     * object, which was used to retrieve this <code>DataLine</code>,
     * specifies at least one fully qualified audio format, the
     * last one will be used as the default format. Opening the
     * line with a specific audio format (e.g.
     * {@link javax.sound.sampled.SourceDataLine#open(javax.sound.sampled.AudioFormat)}) will override the
     * default format.
     *
     * @return current audio data format
     * @see javax.sound.sampled.AudioFormat
     */
    @Override
    public AudioFormat getFormat() {
        return format;
    }

    /**
     * Obtains the maximum number of bytes of data that will fit in the data line's
     * internal buffer.  For a source data line, this is the size of the buffer to
     * which data can be written.  For a target data line, it is the size of
     * the buffer from which data can be read.  Note that
     * the units used are bytes, but will always correspond to an integral
     * number of sample frames of audio data.
     *
     * @return the size of the buffer in bytes
     */
    @Override
    public int getBufferSize() {
        return audioData.length;
    }

    /**
     * Obtains the number of bytes of data currently available to the
     * application for processing in the data line's internal buffer.  For a
     * source data line, this is the amount of data that can be written to the
     * buffer without blocking.  For a target data line, this is the amount of data
     * available to be read by the application.  For a clip, this value is always
     * 0 because the audio data is loaded into the buffer when the clip is opened,
     * and persists without modification until the clip is closed.
     * <p/>
     * Note that the units used are bytes, but will always
     * correspond to an integral number of sample frames of audio data.
     * <p/>
     * An application is guaranteed that a read or
     * write operation of up to the number of bytes returned from
     * <code>available()</code> will not block; however, there is no guarantee
     * that attempts to read or write more data will block.
     *
     * @return the amount of data available, in bytes
     */
    @Override
    public int available() {
        return 0;
    }

    /**
     * Obtains the current position in the audio data, in sample frames.
     * The frame position measures the number of sample
     * frames captured by, or rendered from, the line since it was opened.
     * This return value will wrap around after 2^31 frames. It is recommended
     * to use <code>getLongFramePosition</code> instead.
     *
     * @return the number of frames already processed since the line was opened
     * @see #getLongFramePosition()
     */
    @Override
    public int getFramePosition() {
        if (dataLine != null)
            return framePos - ((dataLine.getBufferSize() - dataLine.available()) / frameSize);
        else
            return 0;
    }

    /**
     * Obtains the current position in the audio data, in sample frames.
     * The frame position measures the number of sample
     * frames captured by, or rendered from, the line since it was opened.
     *
     * @return the number of frames already processed since the line was opened
     * @since 1.5
     */
    @Override
    public long getLongFramePosition() {
        return getFramePosition();
    }

    /**
     * Obtains the current position in the audio data, in microseconds.
     * The microsecond position measures the time corresponding to the number
     * of sample frames captured by, or rendered from, the line since it was opened.
     * The level of precision is not guaranteed.  For example, an implementation
     * might calculate the microsecond position from the current frame position
     * and the audio sample frame rate.  The precision in microseconds would
     * then be limited to the number of microseconds per sample frame.
     *
     * @return the number of microseconds of data processed since the line was opened
     */
    @Override
    public long getMicrosecondPosition() {
        if (isActive())
            return System.currentTimeMillis() * 1000 - timeStarted;
        else
            return framesToMicroseconds(getFramePosition());
    }

    /**
     * Obtains the current volume level for the line.  This level is a measure
     * of the signal's current amplitude, and should not be confused with the
     * current setting of a gain control. The range is from 0.0 (silence) to
     * 1.0 (maximum possible amplitude for the sound waveform).  The units
     * measure linear amplitude, not decibels.
     *
     * @return the current amplitude of the signal in this line, or
     *         <code>{@link javax.sound.sampled.AudioSystem#NOT_SPECIFIED}</code>
     */
    @Override
    public float getLevel() {
        return dataLine.getLevel();
    }

    /**
     * Obtains the <code>Line.Info</code> object describing this
     * line.
     *
     * @return description of the line
     */
    @Override
    public Line.Info getLineInfo() {
        return dataLine.getLineInfo();
    }

    /**
     * Opens the line, indicating that it should acquire any required
     * system resources and become operational.
     * If this operation
     * succeeds, the line is marked as open, and an <code>OPEN</code> event is dispatched
     * to the line's listeners.
     * <p/>
     * Note that some lines, once closed, cannot be reopened.  Attempts
     * to reopen such a line will always result in an <code>LineUnavailableException</code>.
     * <p/>
     * Some types of lines have configurable properties that may affect
     * resource allocation.   For example, a <code>DataLine</code> must
     * be opened with a particular format and buffer size.  Such lines
     * should provide a mechanism for configuring these properties, such
     * as an additional <code>open</code> method or methods which allow
     * an application to specify the desired settings.
     * <p/>
     * This method takes no arguments, and opens the line with the current
     * settings.  For <code>{@link javax.sound.sampled.SourceDataLine}</code> and
     * <code>{@link javax.sound.sampled.TargetDataLine}</code> objects, this means that the line is
     * opened with default settings.  For a <code>{@link javax.sound.sampled.Clip}</code>, however,
     * the buffer size is determined when data is loaded.  Since this method does not
     * allow the application to specify any data to load, an IllegalArgumentException
     * is thrown. Therefore, you should instead use one of the <code>open</code> methods
     * provided in the <code>Clip</code> interface to load data into the <code>Clip</code>.
     * <p/>
     * For <code>DataLine</code>'s, if the <code>DataLine.Info</code>
     * object which was used to retrieve the line, specifies at least
     * one fully qualified audio format, the last one will be used
     * as the default format.
     *
     * @throws IllegalArgumentException if this method is called on a Clip instance.
     * @throws javax.sound.sampled.LineUnavailableException
     *                                  if the line cannot be
     *                                  opened due to resource restrictions.
     * @throws SecurityException        if the line cannot be
     *                                  opened due to security restrictions.
     * @see #close
     * @see #isOpen
     * @see javax.sound.sampled.LineEvent
     * @see javax.sound.sampled.DataLine
     * @see javax.sound.sampled.Clip#open(javax.sound.sampled.AudioFormat , byte[], int, int)
     * @see javax.sound.sampled.Clip#open(javax.sound.sampled.AudioInputStream)
     */
    @Override
    public void open() throws LineUnavailableException {
        throw new IllegalArgumentException("open() may not be called on a Clip!");
    }

    /**
     * Closes the line, indicating that any system resources
     * in use by the line can be released.  If this operation
     * succeeds, the line is marked closed and a <code>CLOSE</code> event is dispatched
     * to the line's listeners.
     *
     * @throws SecurityException if the line cannot be
     *                           closed due to security restrictions.
     * @see #open
     * @see #isOpen
     * @see javax.sound.sampled.LineEvent
     */
    @Override
    public void close() {
        dataLine.close();
        audioData = null;
    }

    /**
     * Indicates whether the line is open, meaning that it has reserved
     * system resources and is operational, although it might not currently be
     * playing or capturing sound.
     *
     * @return <code>true</code> if the line is open, otherwise <code>false</code>
     * @see #open()
     * @see #close()
     */
    @Override
    public boolean isOpen() {
        return dataLine != null && dataLine.isOpen();
    }

    /**
     * Obtains the set of controls associated with this line.
     * Some controls may only be available when the line is open.
     * If there are no controls, this method returns an array of length 0.
     *
     * @return the array of controls
     * @see #getControl
     */
    @Override
    public Control[] getControls() {
        return dataLine.getControls();
    }

    /**
     * Indicates whether the line supports a control of the specified type.
     * Some controls may only be available when the line is open.
     *
     * @param control the type of the control for which support is queried
     * @return <code>true</code> if at least one control of the specified type is
     *         supported, otherwise <code>false</code>.
     */
    @Override
    public boolean isControlSupported(Control.Type control) {
        return dataLine.isControlSupported(control);
    }

    /**
     * Obtains a control of the specified type,
     * if there is any.
     * Some controls may only be available when the line is open.
     *
     * @param control the type of the requested control
     * @return a control of the specified type
     * @throws IllegalArgumentException if a control of the specified type
     *                                  is not supported
     * @see #getControls
     * @see #isControlSupported(javax.sound.sampled.Control.Type control)
     */
    @Override
    public Control getControl(Control.Type control) {
        return dataLine.getControl(control);
    }

    /**
     * Adds a listener to this line.  Whenever the line's status changes, the
     * listener's <code>update()</code> method is called with a <code>LineEvent</code> object
     * that describes the change.
     *
     * @param listener the object to add as a listener to this line
     * @see #removeLineListener
     * @see javax.sound.sampled.LineListener#update
     * @see javax.sound.sampled.LineEvent
     */
    @Override
    public void addLineListener(LineListener listener) {
        dataLine.addLineListener(listener);
    }

    /**
     * Removes the specified listener from this line's list of listeners.
     *
     * @param listener listener to remove
     * @see #addLineListener
     */
    @Override
    public void removeLineListener(LineListener listener) {
        dataLine.removeLineListener(listener);
    }

    public long framesToMicroseconds(int frames) {
        return (long) ((double) frames / format.getSampleRate() * 1000000d);
    }

    public int microsecondsToFrames(long microseconds) {
        return (int) Math.round((microseconds / 1000000d * format.getSampleRate()));
    }
}
